Skip to content

feat: add NangoConnection<I> extractor for nango-powered integration routes#3962

Open
devin-ai-integration[bot] wants to merge 7 commits intomainfrom
devin/1771044867-nango-connection-extractor
Open

feat: add NangoConnection<I> extractor for nango-powered integration routes#3962
devin-ai-integration[bot] wants to merge 7 commits intomainfrom
devin/1771044867-nango-connection-extractor

Conversation

@devin-ai-integration
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot commented Feb 14, 2026

feat: add NangoConnection<I> extractor for nango-powered integration routes

Summary

Introduces a generic NangoConnection<I> axum extractor that resolves a user's Nango connection_id from a Supabase nango_connections table, removing the need for clients to pass connection_id in every request body. The extractor uses marker types for type-safe integration identification.

Key pieces:

  • Supabase migration (nango_connections table) — stores user_id ↔ connection_id per integration, populated by webhook
  • OwnedNangoProxy + OwnedNangoHttpClient in crates/nango — owned variants to solve lifetime issues in extractors; shared header logic extracted to apply_proxy_headers
  • NangoIntegrationId trait + GoogleCalendar/GoogleDrive marker types in crates/api-nango
  • NangoConnection<I> extractor — reads AuthContext for user_id, queries Supabase PostgREST for connection_id, builds ready-to-use HTTP client
  • Webhook handler extended to upsert/delete rows on Nango auth events, returning 500 on transient DB failures so Nango can retry
  • SupabaseClient extracted into supabase.rs with URL-encoded query params and on_conflict=user_id,integration_id for correct upsert behavior
  • api-calendar and api-storage refactoredconnection_id removed from all request bodies, duplicated config.rs/state.rs deleted

Before → After (handler example):

// Before: 3-line preamble + connection_id in body
pub async fn list_calendars(
    State(state): State<AppState>,
    Json(payload): Json<ListCalendarsRequest>, // had connection_id field
) -> Result<...> {
    let proxy = state.nango.integration("google-calendar").connection(&payload.connection_id);
    let http = NangoHttpClient::new(proxy);
    let client = GoogleCalendarClient::new(http);

// After: extractor does it all
pub async fn list_calendars(
    nango: NangoConnection<GoogleCalendar>,
) -> Result<...> {
    let client = GoogleCalendarClient::new(nango.into_http());

Updates since last revision

  • Merge conflict resolved: main refactored stt/llm into separate rate-limited route groups; kept that structure alongside this PR's changes (no-arg calendar::router(), nango_connection_state extension layer).
  • Webhook errors now return 500: Transient DB failures (network issues, Supabase outages) in webhook upsert/delete now propagate as 500 errors via NangoError::Internal, allowing Nango to retry. Previously all errors were silently swallowed with a 200 response.
  • Deletion success gate documented: Added comment explaining why we check payload.success before deleting local state on deletion webhooks — avoids removing a valid local record if revocation failed on Nango's side (per Nango webhook docs, successful deletions set success: true).

Review & Testing Checklist for Human

  • Breaking API change: connection_id is removed from all calendar/storage request bodies. No frontend changes are included in this PR — verify that callers are updated or that this is intentional for the new OAuth flow.
  • Webhook with missing supabase_service_role_key: With the 500 change, a missing key now causes webhook failures (Nango will retry indefinitely). Confirm this is acceptable for dev environments or add a guard that returns 200 when the key is intentionally unconfigured.
  • Extension layer ordering: Confirm NangoConnectionState is available to calendar/storage routes — the .layer(Extension(...)) is added after .nest("/calendar", ...) and .nest("/nango", ...) in apps/api/src/main.rs, which in axum means it applies to all routes above it in the chain. Verify this is correct.
  • Test end-to-end: Connect a Google Calendar integration via Nango → verify webhook populates nango_connections → call /calendar/calendars with just auth token → verify it resolves connection and returns data.

Notes

  • The extractor queries Supabase PostgREST using the user's anon key + auth token (not service role key), relying on the RLS select policy for security. Users can only read their own connections.
  • list_calendars is now a POST with no request body (just the extractor).
  • The OwnedNangoProxy duplicates method signatures from NangoProxy — this is a known tradeoff to solve lifetime issues in axum extractors. Future proxy behavior changes need to be made in both places.

Link to Devin run: https://app.devin.ai/sessions/4feb4eb6926b42c5b1c43ccca430802b
Requested by: @yujonglee

…routes

- Add nango_connections supabase migration table
- Add OwnedNangoProxy + OwnedNangoHttpClient to nango crate
- Add NangoIntegrationId trait with GoogleCalendar/GoogleDrive marker types
- Add NangoConnection<I> axum extractor (resolves connection_id from DB)
- Extend webhook handler to upsert/delete nango_connections
- Refactor api-calendar to use NangoConnection<GoogleCalendar>
- Refactor api-storage to use NangoConnection<GoogleDrive>
- Remove connection_id from request bodies (server-side lookup)
- Remove duplicated config.rs/state.rs from api-calendar and api-storage

Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
@netlify
Copy link

netlify bot commented Feb 14, 2026

Deploy Preview for hyprnote-storybook canceled.

Name Link
🔨 Latest commit 68c1bb5
🔍 Latest deploy log https://app.netlify.com/projects/hyprnote-storybook/deploys/6993367d3647d3000889f80f

@netlify
Copy link

netlify bot commented Feb 14, 2026

Deploy Preview for hyprnote canceled.

Name Link
🔨 Latest commit 68c1bb5
🔍 Latest deploy log https://app.netlify.com/projects/hyprnote/deploys/6993367d9f31d4000826757d

@devin-ai-integration
Copy link
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

devin-ai-integration[bot]

This comment was marked as resolved.

Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration bot and others added 4 commits February 14, 2026 05:57
Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
devin-ai-integration[bot]

This comment was marked as resolved.

…cess comment

Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
Copy link
Contributor Author

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment on lines +18 to +19
#[serde(default, deserialize_with = "hypr_api_env::filter_empty")]
pub supabase_service_role_key: Option<String>,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Duplicate supabase_service_role_key field shadows flattened SupabaseEnv, causing app startup crash

Adding supabase_service_role_key: Option<String> as a named field on Env (line 19) conflicts with the same field inside #[serde(flatten)] pub supabase: hypr_api_env::SupabaseEnv (line 22), which also contains supabase_service_role_key: String (crates/api-env/src/lib.rs:23).

Root Cause

When serde processes #[serde(flatten)], it first matches each key in the input map against explicitly-named fields. The key supabase_service_role_key matches the new top-level Option<String> field and is consumed. The remaining (unmatched) keys are then forwarded to flattened structs. Because the key has already been consumed, SupabaseEnv never sees supabase_service_role_key, and since that field is a required String (not Option), deserialization of SupabaseEnv fails.

This makes envy::from_env().expect("Failed to load environment") at apps/api/src/env.rs:55 panic on startup, crashing the application.

Impact: The API server cannot start at all. This is a complete regression.

Fix: Remove the new supabase_service_role_key field from Env and instead pass Some(env.supabase.supabase_service_role_key.clone()) in main.rs where NangoConfig::new is called, since SupabaseEnv already provides this value as a required field.

Prompt for agents
In apps/api/src/env.rs, remove lines 18-19 (the new supabase_service_role_key field). The field already exists inside the flattened SupabaseEnv struct and having it as a named field on Env shadows it, preventing SupabaseEnv from deserializing.

Then in apps/api/src/main.rs around line 74-78, change:
  let nango_config = hypr_api_nango::NangoConfig::new(
      &env.nango,
      &env.supabase,
      env.supabase_service_role_key.clone(),
  );
to:
  let nango_config = hypr_api_nango::NangoConfig::new(
      &env.nango,
      &env.supabase,
      Some(env.supabase.supabase_service_role_key.clone()),
  );

This uses the already-available supabase_service_role_key from SupabaseEnv instead of a duplicate field.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +53 to +80
if payload.r#type == "auth" && payload.success && payload.operation != "deletion" {
state
.supabase
.upsert_connection(
&payload.end_user.end_user_id,
&payload.provider_config_key,
&payload.connection_id,
&payload.provider,
)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to upsert nango connection");
NangoError::Internal(e.to_string())
})?;
}

// Nango sends deletion webhooks with `success: true` on successful revocation.
// We gate on `success` to avoid deleting local state if revocation failed on Nango's side.
if payload.r#type == "auth" && payload.success && payload.operation == "deletion" {
state
.supabase
.delete_connection(&payload.end_user.end_user_id, &payload.provider_config_key)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to delete nango connection");
NangoError::Internal(e.to_string())
})?;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Webhook handler returns error to Nango on missing service role key

When supabase_service_role_key is None, the service_role_key() method at crates/api-nango/src/supabase.rs:20-26 returns Err(NangoError::Internal(...)). The webhook handler at crates/api-nango/src/routes/webhook.rs:53-79 propagates this as an HTTP error response to Nango. The PR description says "webhooks will log errors but return ok if not set", but the actual behavior is to return an error. Nango may retry the webhook, causing repeated failures. Consider whether the webhook should silently succeed (log + return ok) when the service role key is not configured, rather than returning an error to Nango.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +53 to +67
if payload.r#type == "auth" && payload.success && payload.operation != "deletion" {
state
.supabase
.upsert_connection(
&payload.end_user.end_user_id,
&payload.provider_config_key,
&payload.connection_id,
&payload.provider,
)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to upsert nango connection");
NangoError::Internal(e.to_string())
})?;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Webhook assumes end_user.end_user_id is a valid UUID for Supabase FK

The nango_connections table has user_id uuid NOT NULL with a foreign key to auth.users(id) (supabase/migrations/20250214000000_create_nango_connections.sql:14-15). The webhook handler passes payload.end_user.end_user_id (a plain String from crates/nango/src/webhook.rs:42) directly to upsert_connection at crates/api-nango/src/routes/webhook.rs:57. If Nango sends an end_user_id that is not a valid UUID or does not match an existing auth.users.id, the Supabase INSERT will fail with a FK constraint violation, surfacing as an internal error. This depends on how the Nango Connect Session is configured upstream — the caller must ensure end_user.id is set to the Supabase user UUID when creating the session.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant